SVG-Based Program Milestone Reporting User Manual

This document intends to help the user configure gantt_ui_config.json to produce Gantt Chart with desired visual effects.

Generated: 2026-04-27 15:54:12 Config file: gantt_ui_config.json Renderer: Render_gantt_svg.py (modified 2026-04-27 15:29:16)

1. Introduction

The renderer produces a single, publication quality SVG that is designed to be pasted straight into a PowerPoint slide. The whole look and feel of that SVG is governed by one file, gantt_ui_config.json.

Every change you want to make to the chart, from switching to a T minus countdown, to widening the swim lane labels, to making the arrows dashed, to rescaling everything to fit a 16 by 9 slide, is a JSON edit and a rerun. No fight with Office drawing objects.

How to use this manual. Each numbered section covers one top level block in the config file, in the same order it appears. Where a parameter has an obvious visual effect, there is a figure showing the effect of changing it. At the end there is a set of recipes that bundle several edits together to hit common outcomes like "fit the chart to a 16 by 9 slide" or "switch from weekly to quarterly".

2. At a glance

A snapshot of the current configuration so you know what you are working with before you start tweaking:

the current configuration

Data source mode
Mode B - Cache drives the SVG mode-b
Canvas sizing
custom 2680 x 720 px
Visible window
2026-03-15 to 2026-05-20, span weekly (66 days, roughly 9 weekly buckets)
T-minus axis
enabled, baseline 2026-05-22, unit weeks
Swim lanes
7 lanes pinned in order: SDD, Telecomms, Infrastructure, M&E and BMS, SCSs Issued ...
Overlap handling
default vertical, with 4 per-lane override(s): Test Assurance & Risk Assessment, Configuration Document(SDD), Configuration Deep Dives ...
Arrows
stroke 1.8 px, dasharray none, opacity 0.85
Milestone labels
exterior labels ON

3. How the pipeline works

Before touching any settings, it helps to understand what the script actually does when you run it. There are two data paths, selected by data_source.source. Everything else in the config affects layout, not data.

Mode A - source = "Excel" (production run, refreshes the cache) Excel workbook (single source of truth) extract_data() openpyxl reads sheet gantt_data.json (cache, overwritten) gantt_output.svg timestamped file gantt_data_audit.html Mode B - source = "json" (fast layout iteration, cache is the input) gantt_data.json (you edit this freely) gantt_output.svg rerun fast, no Excel gantt_excel_json_diff.mht side by side compare promote to Excel once satisfied
Figure 3.1 The two data pipelines. Mode A writes the cache. Mode B reads it.

Four output files can appear in the working folder:

4. data_source

This block decides where the milestone data comes from. It is the single most important switch in the file, because it determines whether a run will touch the Excel or not.

the current data_source block

{
  "source": "json",
  "_source_comment": "Mode A ('Excel') extracts from the workbook. Mode B ('json') reads from the cache for rapid layout iteration.",
  "excel_file_name": "2026.04.21 Technical - Entry Criteria Program.xlsx",
  "_excel_file_name_comment": "Path to the source Excel file. Can be relative or absolute.",
  "json_file_name": "gantt_data.json",
  "_json_file_name_comment": "Cache file written by the tool.",
  "excel_sheet": "Schema Normalisation",
  "_excel_sheet_comment": "Target worksheet containing the data."
}

Mode A, source = "Excel"

This is production mode. The script opens the workbook, reads the sheet named in excel_sheet, extracts every row that has a Function Group and a valid End Date that falls inside the timeline window, writes those rows out to the JSON cache, runs the data quality audit, and then renders the SVG.

Mode A is what you want at the end of every planning cycle. It takes Excel, the team's single source of truth, and rolls it forward into the JSON cache.

Mode B, source = "json"

This is layout iteration mode, and it is the reason this tool exists in its current form. Instead of reading the Excel, the renderer loads gantt_data.json directly and ignores the workbook for input purposes.

Why is that useful? Because it lets you play with the data without touching the source of truth. You can:

Then, once the chart looks right, you run the script one more time still in Mode B. Because the Excel is still present on disk, the script will read it in parallel to the cache and produce gantt_excel_json_diff.mht. That file is a side by side table showing every milestone whose cache value differs from the Excel, highlighted in amber. You take that report, open Excel, and type exactly those changes into the cells. That keeps Excel authoritative without forcing you to edit Excel every time you want to try a layout idea.

Typical cycle. Flip to Mode B, iterate on gantt_data.json until the SVG looks right, read the diff MHT, mirror the diffs into Excel, then flip back to Mode A and run once more to regenerate the cache cleanly.

The other three keys

KeyWhat it does
excel_file_namePath to the workbook. Can be relative (resolved next to the script) or absolute. Only consulted in Mode A, or in Mode B when a diff report is being produced.
json_file_namePath to the cache. In Mode A it is written. In Mode B it is read.
excel_sheetExact worksheet tab name to read from. If not found, the script falls back to Schema Normalisation, then to the active sheet.
Close the workbook before running. If Excel is still open with the file, Windows places a lock on it. The script will detect this and abort with a clear message, but the cleanest flow is to close the workbook, run the script, then reopen it.

5. svg_dimensions

This block decides the physical size of the SVG canvas. It has one key that matters above all others, fit_mode, and two numeric keys that only take effect when fit_mode is set to "custom".

the current svg_dimensions block

{
  "fit_mode": "custom",
  "_fit_mode_comment": "Use 'custom' to define exact WxH, or 'natural' to scale based on contents.",
  "custom_width_px": 2680,
  "_custom_width_px_comment": "The exact canvas width in pixels.",
  "custom_height_px": 720,
  "_custom_height_px_comment": "The exact canvas height in pixels.",
  "preserve_aspect_ratio": "xMidYMid meet",
  "_preserve_aspect_ratio_comment": "Keeps the aspect ratio proportional when pasting into PowerPoint."
}

fit_mode

Five values are recognised. The renderer uses the target size to work backwards and compute a new column_width_px that will make the chart fill the canvas horizontally. In other words, you do not choose both the canvas width and the column width, you choose one and the other is derived.

ValueTarget widthTarget heightWhen to use
naturalsum of columnssum of lanesYou want each column to be exactly column_width_px wide, and you do not care if the chart ends up wider or narrower than the slide.
customcustom_width_pxcustom_height_pxYou know the exact pixel budget. This is the most common choice for PowerPoint.
powerpoint_16_91280720Standard widescreen slide at standard def.
powerpoint_16_9_hd19201080Standard widescreen slide at full HD.
powerpoint_4_3960720Legacy 4 by 3 decks.
a4_landscape1169826A4 at 100 dpi for print.
How the width override actually works. When fit_mode is anything other than natural, the renderer computes col_w = (target_width - label_width) / number_of_columns. So if you have a 1680 pixel target, a 220 pixel swim lane label column, and 10 weekly columns, each column will be rendered at 146 pixels wide, regardless of what you put in timeline.column_width_px.

preserve_aspect_ratio

This is an SVG attribute that controls what happens when the SVG is embedded in a container of a different aspect ratio (for example, a PowerPoint placeholder that is not exactly 1680 by 720). The default "xMidYMid meet" keeps the whole chart visible, centred, with any leftover space as margin. It is almost always the right setting. Alternatives are "xMidYMid slice", which crops to fill, and "none", which stretches.

6. timeline

The timeline block controls what time period the chart covers, how time is bucketed into columns, and the baseline column width before the fit_mode override kicks in.

the current timeline block

{
  "span": "weekly",
  "_span_comment": "Bucketing logic. Options: weekly, monthly, quarterly.",
  "start_date": "2026-03-15",
  "_start_date_comment": "Start of the chart's visible window.",
  "end_date": "2026-05-20",
  "_end_date_comment": "End of the chart's visible window.",
  "column_width_px": 110,
  "_column_width_px_comment": "Base width of one time column. Overridden dynamically if fit_mode = custom."
}

span

Three values are accepted, "weekly", "monthly", and "quarterly". The renderer snaps the start date to the beginning of the chosen bucket and then walks forward one bucket at a time until it passes the end date.

span = "weekly" (snaps to Monday) Wk11 Mar16 Wk12 Mar23 Wk13 Mar30 Wk14 Apr06 Wk15 Apr13 Wk16 Apr20 Wk17 Apr27 Wk18 May04 span = "monthly" (snaps to the 1st) Mar-26 Apr-26 May-26 span = "quarterly" (snaps to Q1/Q2/Q3/Q4 start) Q1 2026 Q2 2026 Identical date window (Mar 15 to May 17), rendered with three different bucket widths.
Figure 6.1 The same date range bucketed three different ways.

start_date and end_date

These two dates define the visible window of the chart. Any milestone whose end date falls inside this window is rendered. Anything outside is silently dropped, with a tally in the summary block of gantt_data.json under skipped_out_of_bounds. This is how you narrow a chart down to, say, just the next two months of activity even when the Excel plan covers two years.

Dates are inclusive on both ends. A milestone dated exactly on end_date is kept. A milestone one day later is not.

column_width_px

The baseline width in pixels of a single bucket. This is the value that is used when svg_dimensions.fit_mode = "natural". For any other fit mode, the column width is overridden at render time so the chart exactly fills the chosen canvas width, and this key is effectively ignored. Keep it sensible anyway, because Mode natural is a useful debugging fallback.

7. relative_time_axis

This adds a secondary header row between the Year row and the Time row. Instead of showing calendar dates, it shows a countdown or count up relative to a reference date that you choose. This is how you get the familiar "T minus 8 weeks" style on an executive chart.

the current relative_time_axis block

{
  "enabled": true,
  "_enabled_comment": "If true, adds a secondary T-minus header row.",
  "baseline_date": "2026-05-22",
  "_baseline_date_comment": "The T+0 anchor date.",
  "unit": "weeks",
  "_unit_comment": "Unit for the countdown (weeks, months, quarters).",
  "zero_label": "T+0",
  "_zero_label_comment": "Text to show precisely on the baseline date.",
  "format_positive": "T+{n}",
  "_format_positive_comment": "Format string for dates after the baseline.",
  "format_negative": "T-{n}",
  "_format_negative_comment": "Format string for dates before the baseline."
}

How the countdown is computed

The renderer walks every column in the timeline and asks "how many units between this column and the baseline?" For weeks it snaps both dates to their respective Mondays first, so the answer is always a clean integer. For months it uses year and month arithmetic. For quarters it uses year and quarter arithmetic.

Then it feeds that integer into format_positive or format_negative, substituting {n} with the absolute value. On the exact baseline column it shows zero_label verbatim instead.

Baseline = 2026-05-22 with unit = "weeks", format_negative = "T-{n}" 2026 T-10 T-9 T-8 T-4 T-3 T-2 T-1 T+0 Wk11 Mar16 Wk12 Mar23 Wk13 Mar30 Wk17 Apr27 Wk18 May04 Wk19 May11 Wk20 May18 Wk21 May25 zero_label wins Negative values use format_negative. Positive values use format_positive. The baseline column itself shows zero_label verbatim.
Figure 7.1 The T minus row sits between the year row and the time row.

Changing the unit

When you change unit, you usually want to change span to match, otherwise the middle row will have repeated values inside a single bucket.

spanRecommended unit
weeklyweeks
monthlymonths
quarterlyquarters
Setting enabled to false. When enabled is false, the middle row disappears entirely and its pixel budget is removed from the total header height. Nothing else in the chart shifts around.

8. node_styling (diamonds and labels)

This block governs everything about how each milestone is drawn. It is divided into two parts: the diamond itself, and the exterior text label that sits next to it.

the current node_styling block

{
  "diamond_size_px": 20,
  "_diamond_size_px_comment": "The size (radius) of the milestone diamond.",
  "diamond_stroke_color": "#333333",
  "_diamond_stroke_color_comment": "Border color around the diamond.",
  "diamond_stroke_width_px": 1.5,
  "_diamond_stroke_width_px_comment": "Thickness of the diamond's border.",
  "diamond_text_display_for_all_diamonds": "yes",
  "_diamond_text_display_comment": "Set to 'yes' to show exterior milestone activity labels, or 'no' to hide them.",
  "label_wrap_chars": 22,
  "_label_wrap_chars_comment": "Wrap activity text onto a new line if it exceeds this character length.",
  "label_font_size_px": 22,
  "_label_font_size_px_comment": "Font size for the milestone description text.",
  "label_font_color": "#1F2937",
  "_label_font_color_comment": "Dark elegant gray text. Maps to 'text_dark'.",
  "label_gap_px": 25,
  "_label_gap_px_comment": "Distance between the diamond tip and the description text.",
  "label_line_height_px": 28,
  "_label_line_height_px_comment": "Vertical distance between wrapped text lines."
}
diamond_size_px = half-diagonal (distance from centre to tip) diamond_stroke_color diamond_stroke_width_px Rev 1.0 SDD Issued to support TSS Submission label_gap_px label_line_height_px label_wrap_chars controls where the line breaks label_font_size_px and label_font_color
Figure 8.1 Anatomy of a milestone diamond with all node_styling knobs annotated.

Diamond geometry

KeyEffect
diamond_size_pxHalf the diagonal of the diamond, measured in pixels. A value of 14 means the diamond is 28 pixels tall and 28 pixels wide. Increasing it makes every milestone more prominent but also forces the swim lane to grow taller.
diamond_stroke_colorHEX colour of the border drawn around the diamond. The fill of the diamond itself comes from the swim lane colour.
diamond_stroke_width_pxThickness of that border in pixels. A value of 0 removes the border.

The label toggle

diamond_text_display_for_all_diamonds is the single most impactful switch in this block. Set it to "yes" and every diamond gets a multi line activity label drawn below or above it. Set it to "no" and the diamonds stand on their own, stripped of all text.

Rev 0.1 SDD Issued SCSs Issued Rev 1 SCS Deep Dive 1 Telecomms "yes" (default)
"no"

Label typography

KeyEffect
label_wrap_charsApproximate character count per line. The wrapper greedily packs whole words until adding one more would exceed the limit.
label_font_size_pxPixel size of the activity text. 14 to 16 pixels reads well at slide distance.
label_font_colorAny HEX. The default dark grey reads clearly on white and does not fight with the brightly coloured diamonds.
label_gap_pxVertical distance between the bottom tip of the diamond and the top of the first line of text.
label_line_height_pxVertical distance between consecutive wrapped lines.

9. arrow_styling

This block controls every dependency arrow in the chart. Arrows are routed orthogonally, meaning they only ever travel horizontally or vertically in ninety degree turns. The arrow colour is not controlled here; each arrow inherits the colour of the swim lane the predecessor sits in.

the current arrow_styling block

{
  "stroke_width_px": 1.8,
  "_stroke_width_px_comment": "Line weight (thickness) of the connection arrows. Change this from  5,3  to  none ",
  "stroke_dasharray": "none",
  "_stroke_dasharray_comment": "Line type: '5,3' makes a dashed line. Set to 'none' or '' for a solid line.",
  "opacity": 0.85,
  "_opacity_comment": "Transparency of the arrow line (1.0 = fully opaque).",
  "marker_width": 8,
  "_marker_width_comment": "Size of the arrowhead pointing at the target milestone.",
  "marker_height": 6,
  "_marker_height_comment": "Height of the arrowhead."
}

stroke_width_px

The thickness of the line. Values under 1 look like ghosts on a projector. Values above 3 start to visually compete with the diamonds. The sweet spot for a slide is 1.5 to 2.

0.8 1.8 (default) 3 5
Figure 9.1 Four stroke widths on the same arrow.

stroke_dasharray

This is the single key that switches the arrow between solid and dashed. The value is an SVG stroke dash pattern. The first number is the length of the ink segment. The second number is the length of the gap.

"none" solid "5,3" short dash (common) "10,4" long dash "2,2" dotted "8,3,2,3" dash dot
Figure 9.2 Common stroke_dasharray values.
When to use dashed. A dashed arrow is a strong visual cue that the dependency is soft, proposed, or contingent. A solid arrow reads as committed.

opacity

A number from 0 to 1. The default of 0.85 is a subtle choice, it keeps the line clearly visible but softens it just enough that on a slide with a lot of arrows, the lines recede a touch and let the diamonds dominate.

1.0 0.85 (default) 0.5 0.25
Figure 9.3 Four opacities on the same arrow.

marker_width and marker_height

These two keys control the arrowhead triangle that sits at the end of every dependency line. marker_width is how long the triangle is in the direction of travel. marker_height is how thick it is perpendicular to travel.

4 x 3 (tiny) 8 x 6 (default) 14 x 10 20 x 14 (large)
Figure 9.4 Arrowhead size is driven by marker_width and marker_height.
Keep arrowhead proportional to stroke width. A reliable ratio is marker_width equal to 4 or 5 times the stroke_width_px.

10. overlap_handling

When two or more milestones in the same swim lane fall close enough in time that their diamonds would overlap horizontally, the renderer has to decide what to do. This block defines that behaviour globally, and the swimlanes.overrides block lets specific lanes deviate.

the current overlap_handling block

{
  "default_mode": "vertical",
  "_default_mode_comment": "Strategy to handle overlaps: 'vertical' (stacks them on top of each other) or 'horizontal' (spreads them wide).",
  "collision_width_px": 140,
  "_collision_width_px_comment": "Horizontal hit-box used to detect if two diamonds are colliding.",
  "vertical": {
    "spacing_per_level_px": 45,
    "_spacing_per_level_px_comment": "How far down a stacked milestone drops to avoid collision."
  },
  "horizontal": {
    "min_diamond_gap_px": 55,
    "_min_diamond_gap_px_comment": "Minimum horizontal gap between spread-out diamonds.",
    "alternate_label_side": true,
    "_alternate_label_side_comment": "If true, labels zig-zag above and below the baseline to avoid each other.",
    "label_offset_above_px": 28,
    "_label_offset_above_px_comment": "How high above the baseline top-labels sit.",
    "label_offset_below_px": 12,
    "_label_offset_below_px_comment": "How low below the baseline bottom-labels sit.",
    "fallback_to_vertical_after_n": 5,
    "_fallback_to_vertical_after_n_comment": "If a cluster exceeds this count, starts stacking vertically too."
  }
}

collision_width_px

This is the hit box. Two diamonds are considered to be colliding if they are within this many pixels of each other horizontally. A larger value is more aggressive, making the overlap logic kick in earlier.

default_mode = "vertical"

Under vertical mode, when a collision is detected, the later diamond drops down one "level" where each level is spacing_per_level_px pixels deep. Stacks can get arbitrarily tall, and the swim lane expands automatically to contain them.

Vertical stacking, spacing_per_level_px = 45 Milestone A Milestone B Milestone C Milestone D spacing per level
Figure 10.1 Vertical mode. A, B, and C collide in time and drop onto levels 0, 1, and 2.

default_mode = "horizontal"

Horizontal mode keeps diamonds on the same baseline and spreads them outwards along the time axis. It is particularly good for lanes where there are three or four milestones in a tight cluster.

Horizontal spreading with alternate_label_side = true Deep Dive 1 Deep Dive 3 Deep Dive 2 Deep Dive 4 min_diamond_gap_px label_offset_above_px label_offset_below_px
Figure 10.2 Horizontal mode. Four clustered diamonds are spread apart, with labels zigzagging.
Horizontal mode visual cue. When a diamond is moved sideways from its true date, a faint vertical tick is drawn at the true date and a light dashed line connects the tick to the moved diamond. This keeps the chart honest: anyone can see that the diamond has been shifted for layout reasons.

11. swimlanes

The swimlanes block controls the left hand label column, the vertical order of the lanes, and per lane overrides for overlap mode.

the current swimlanes block

{
  "lane_order": [
    "SDD",
    "Telecomms",
    "Infrastructure",
    "M&E and BMS",
    "SCSs Issued",
    "CBR",
    "Risk Assessments"
  ],
  "_lane_order_comment": "Explicit top-to-bottom order. Missing lanes are appended alphabetically.",
  "label_width_px": 220,
  "_label_width_px_comment": "Width of the left-hand column containing the swim lane titles.",
  "label_font_size_px": 20,
  "label_line_height_px": 38,
  "_label_font_size_px_comment": "Font size of the swim lane titles.",
  "label_font_color": "#FFFFFF",
  "_label_font_color_comment": "Color of the swim lane titles.",
  "label_wrap_chars": 24,
  "_label_wrap_chars_comment": "Wrap long swim lane names at this character limit.",
  "top_padding_px": 40,
  "_top_padding_px_comment": "Distance from the top boundary of the lane to the diamonds.",
  "bottom_padding_px": 20,
  "_bottom_padding_px_comment": "Empty space below the diamonds in a lane.",
  "fallback_color": "#4472C4",
  "_fallback_color_comment": "Color used if the Excel 'WBS Colour' column is blank.",
  "row_fill_uses_lane_color": true,
  "_row_fill_uses_lane_color_comment": "If true, the swim lane row background uses the lane's specific color instead of the page background.",
  "row_shade_opacity": 0.15,
  "_row_shade_opacity_comment": "Opacity of the row background fill (e.g., 0.05 for a very light tint, 0.0 for transparent).",
  "alternate_column_shading": true,
  "_alternate_column_shading_comment": "If true, every second timeline column has no background tint, creating a vertical striped effect.",
  "overrides": {
    "Test Assurance & Risk Assessment": {
      "overlap_mode": "horizontal"
    },
    "Configuration Document(SDD)": {
      "overlap_mode": "horizontal"
    },
    "Configuration Deep Dives": {
      "overlap_mode": "horizontal"
    },
    "QR RCCP Approval": {
      "overlap_mode": "horizontal"
    }
  }
}

lane_order

An ordered list of Function Group names. Lanes listed here appear top to bottom in the chart in exactly this order. Any swim lane present in the data but not listed here is appended at the bottom, alphabetically.

Renaming in Excel, updating here. The lane names must match the Function Group column verbatim. If you rename a lane in Excel you must update this list too, otherwise the lane falls back to alphabetical ordering.

The left label column

Every lane has its label painted in a solid coloured block on the left, label_width_px pixels wide. The colour of the block is the lane colour, which is derived from the WBS Colour Code column in Excel.

SDD Telecomms Risk Assessments label_width_px fallback_color applies when a lane has no colour in data label_font_color on top of the lane colour label_wrap_chars wraps long lane names onto two or more lines; label_line_height_px spaces the lines vertically
Figure 11.1 Anatomy of the left label column.

top_padding_px and bottom_padding_px

These are the vertical cushions inside every lane. top_padding_px is the distance from the upper boundary of the lane to the diamonds on level 0. bottom_padding_px is the empty space below the last line of label text.

Lane height is not fixed. Each lane grows as tall as it needs to be to contain its tallest stack. If the total content height comes in under the custom_height_px target, the renderer distributes the leftover vertically, padding each lane equally to fill the canvas.

Row Shading and Striping

The configuration provides options to subtly tint the background of the timeline grid to make reading across the chart easier:

KeyEffect
row_fill_uses_lane_colorIf set to true, the horizontal row spanning the timeline will inherit the swim lane's specific color (instead of the default page background).
row_shade_opacityControls the transparency of the row fill. Values should be kept very low (e.g., 0.05 to 0.15) so the tint is subtle and does not overpower the milestone diamonds. Set to 0.0 for completely invisible rows.
alternate_column_shadingIf set to true, every odd-indexed time column drops the background fill entirely. This creates a vertical striped effect (like a checkerboard) making it much easier to track dates vertically down a tall chart.

overrides

A per lane escape hatch for overlap mode. If the global overlap_handling.default_mode is set to vertical but you have one lane where horizontal spread reads better, add an entry here pointing that single lane name at "overlap_mode": "horizontal".

The header is built from up to three stacked rows. From top to bottom: the year row, the optional T minus row, and the time row. Each row has its own height, background, font size, and font colour.

the current header_styling block

{
  "height_year_px": 38,
  "_height_year_px_comment": "Height of the top-most (Year) row.",
  "year_bg": "#002855",
  "_year_bg_comment": "Dark elegant blue. Maps to 'header_bg'.",
  "year_font_color": "#FFFFFF",
  "_year_font_color_comment": "Text color in the year row.",
  "year_font_size_px": 26,
  "_year_font_size_px_comment": "Font size for the year text.",
  "height_t_minus_px": 32,
  "_height_t_minus_px_comment": "Height of the middle T-minus row.",
  "t_minus_bg": "#002855",
  "_t_minus_bg_comment": "Background of the T-minus row. Set identical to 'year_bg' for a unified block.",
  "t_minus_font_color": "#FFFFFF",
  "_t_minus_font_color_comment": "Text color in the T-minus row.",
  "t_minus_font_size_px": 22,
  "_t_minus_font_size_px_comment": "Font size for the T-minus text.",
  "height_time_px": 46,
  "_height_time_px_comment": "Height of the bottom (Months/Weeks) header row.",
  "time_bg": "#002855",
  "_time_bg_comment": "Background for the bottom header row.",
  "time_font_color": "#FFFFFF",
  "_time_font_color_comment": "Text color for the bottom header row.",
  "time_font_size_px": 22,
  "_time_font_size_px_comment": "Font size for the bottom header row."
}
2026 height_year_px T-6 T-5 T-4 T-3 T-2 T-1 T+0 T+1 height_t_minus_px Wk14 Apr06 Wk15 Apr13 Wk16 Apr20 Wk17 Apr27 Wk18 May04 Wk19 May11 Wk20 May18 Wk21 May25 height_time_px The three rows share background colour by default. Giving each row a distinct background makes the visual hierarchy more obvious but also more cluttered. Most executive decks keep all three backgrounds identical and rely on font size alone to differentiate them.
Figure 12.1 Three header rows, each independently styled.

13. page

The catch all for chart wide styling.

the current page block

{
  "background_color": "#FFFFFF",
  "_background_color_comment": "The base color behind the entire chart. Maps to 'bg'.",
  "border_color": "#E5E7EB",
  "_border_color_comment": "Color of the outer border box. Maps to 'border'.",
  "border_width_px": 2,
  "_border_width_px_comment": "Thickness of the outer border.",
  "font_family": "Arial, sans-serif",
  "_font_family_comment": "CSS font family applied to all text in the SVG."
}
KeyEffect
background_colorThe colour behind everything. Usually white for print and for slides with white backgrounds.
border_colorColour of the outer chart border.
border_width_pxThickness of the outer border. Set to 0 for no border.
font_familyCSS font family string, applied to every piece of text in the SVG.

14. reports

the current reports block

{
  "audit_html": "gantt_data_audit.html",
  "_audit_html_comment": "Output filename for data quality review.",
  "diff_mht": "gantt_excel_json_diff.mht",
  "_diff_mht_comment": "Output filename for version control comparisons.",
  "user_manual_html": "User Manual - SVG-Based Program Milestone Reporting.html",
  "_user_manual_html_comment": "Output filename for the regenerated HTML user manual."
}

File names for the three reports the script can produce: the data quality audit HTML, the Excel versus JSON diff MHT, and this user manual HTML. All three can be absolute paths if you want them written somewhere other than next to the script.

The audit HTML

Generated every time the script reads Excel. For every milestone in the sheet it flags missing predecessors, dangling references, date inversions, and predecessors that point at rows without end dates.

The diff MHT

Generated only in Mode B. It walks every milestone in the timeline window and compares the cached JSON value to the freshly read Excel value for five fields: Function Group, End Date, Activity, WBS Colour, and Predecessors. Any differing cell is highlighted amber.

The user manual HTML

The file you are reading now. Regenerated automatically on every run, so the code blocks above always reflect the live config. If you are writing a handover document, this is the one file that is always current.

15. Recipes

15.1 Fit the chart to a 1280 by 720 PowerPoint slide

"svg_dimensions": {
  "fit_mode": "powerpoint_16_9"
}

15.2 Switch from weekly to quarterly with matching T minus

"timeline":            { "span": "quarterly" },
"relative_time_axis":  { "unit": "quarters" }

15.3 Remove the T minus row entirely

"relative_time_axis": { "enabled": false }

15.4 Strip every exterior label off the diamonds

"node_styling": { "diamond_text_display_for_all_diamonds": "no" }

15.5 Make every arrow dashed for a proposed plan

"arrow_styling": {
  "stroke_dasharray": "5,3",
  "opacity": 0.7
}

15.6 Force one lane to spread horizontally while everything else stacks vertically

"swimlanes": {
  "overrides": {
    "Configuration Deep Dives": { "overlap_mode": "horizontal" }
  }
}

15.7 Narrow the visible window to the next six weeks

"timeline": {
  "start_date": "2026-04-24",
  "end_date":   "2026-06-05",
  "span":       "weekly"
}

15.8 Make the chart presentable in monochrome printing

"node_styling":   { "diamond_stroke_width_px": 2.5 },
"arrow_styling":  { "opacity": 1.0, "stroke_width_px": 2.4 }

16. Troubleshooting

SymptomLikely causeFix
Chart is emptyNo milestone dates fall inside the timeline windowWiden timeline.start_date and timeline.end_date, or check _summary.skipped_out_of_bounds.
A lane appears in the wrong orderThe lane name in lane_order does not exactly match ExcelCopy the Function Group value from Excel and paste it into lane_order verbatim.
A predecessor arrow is missingEither the predecessor is outside the window, or the Dependencies column has a typoOpen the audit HTML. Dangling references are flagged in red.
Diamonds overlap and look crampedOverlap mode is not resolving them because they are not within collision_width_pxIncrease overlap_handling.collision_width_px to trigger the resolver sooner.
Chart is wider than the slidefit_mode is set to naturalSwitch to custom or one of the PowerPoint presets.
Arrows look faint on projectorDefault opacity of 0.85 plus a thin strokeBump opacity to 1.0 and stroke_width_px to 2.2.
Script aborts with a lock messageExcel workbook is openClose the workbook and rerun. The Mode B cache is unaffected.
The diff MHT never appearsYou are running in Mode A, or the Excel path is wrongDiff is Mode B only. Confirm data_source.source = "json" and the Excel file is reachable.

SVG-Based Program Milestone